﻿// Copyright 2007 Google Inc.
// All Rights Reserved.

/**
 * @fileoverview Gadget Static Validation Libray
 * @author wwen@google.com (William Wen)
 *
 * This library will contain the rules of gadget validation
 * and the code to execute the validation rules
 *
 * To use the library, instantiate the GadgetStaticValidator class,
 * pass in the gadget XML spec.
 *
 * var gsv = new GadgetStaticValidator(xml);
 *
 * then, call the validate method, passing in
 * 1. the rule name or tag name (tag name can be a regular expression fragment)
 * 2. the callback method upon all completion
 * 3. the callback method for completion of each rule
 *
 * gsv.validate("consumer|ads", onAllComplete, onEachRuleComplete);
 *
 * Where the callback signatures are:
 *
 * function onAllComplete(success, array_of_validation_results) { ... }
 * function onEachRuleComplete(validation_result)
 *
 * and a validation result is an object that contains:
 *   - success: true/false
 *   - rule_name: name of the rule, i.e. "XML_DECLARATION"
 *   - error: high level description of the error
 *   - description: detailed description of the error
 *   - details: specific example snippets that triggered the error
 *
 * To create a rule, add new object to the rules array that contains:
 *   - tags: filterable tags on the validate method, i.e. "consumer"
 *   - rule_name: name of the rule in ALL_CAPS_WITH_UNDERSCORES
 *   - check: a function that performs the validation and
 *       returns a result object.  Be sure to describe the error in detail.
 *
 */

/**
 * The result object contains information about the validation, whether it
 * passed/failed, description messages, etc.  This is passed back to the calling
 * environment through the validation callback methods.
 *
 * The error/description messages are left blank and will be assigned values
 * within GGE using message bundles.  If a validation rule needs to attach
 * additional info to the returned message, append them to the details array.
 * GGE or other gadgets can iterate through each detail and include additional
 * info in the messaging.
 *
 * Example result and what should be contained in each property:
 * - success: false
 * - rule_name: 'VERIFY_ASSETS_ARE_VALID'
 * - error: 'All external URLs must be valid'
 * - details: new Array('http://xyz.com', 'http://fakedomain.com')
 *
 * @param {Boolean} success True if the validation passed.  False otherwise.
 * @param {String} rule_name The rule name of the validation performed.
 *      with information on how to resolve it, links, etc.
 * @constructor
 */
function GSV_ValidationResult(success, rule_name) {
  this.success = success;
  this.rule_name = rule_name;
  this.error = '';          // Will be set in gge.xml
  this.description = '';    // Will be set in gge.xml
  this.details = new Array();
}

/**
 * The total result of the validation which is used to pass back information
 * about the validation results to the callback.
 *
 * @this {StaticValidator}
 */
function GSV_TotalResult() {
  this.success = false;
  this.results = new Array();
  this.dimensions = new Array();
}

/**
 * Static validation class.  Performs static analysis of the gadget XML.
 * @this {StaticValidator}
 * @param {GadgetSpec} gadgetSpec GadgetSpec object contains methods to
 *      retrieve information about this gadget.
 */
function GadgetStaticValidator(gadgetSpec) {
  // Public methods
  // TODO(daniellee): Separate into private/public methods
  this.validate = GSV_validate;
  this.validateCallback = GSV_validateCallback;
  this.setValidateSize = GSV_setValidateSize;

  // Public properties
  // TODO(daniellee): Separate into private/public properties
  this.gadgetSpec = gadgetSpec;
  this.dimensions = new Array();
  // -1 is flag for no gadget URL
  this.isGadgetUTF8 = -1;
  this.validateSize = null;   // Size Array: [width, height]

  var _this = this;

  var gadgetUrl = gadgetSpec.getSourceUrl();
  if (gadgetUrl) {
    // increment the counter
    GSV_progressCounter++;

    var xmlhttp = GSV_getXmlHttp();

    // /ig/adsv is available to the www.gmodules.com domain
    // to call it from www.google.com you need to pass the edit token
    var adsv_url = '/ig/adsv?nocache=1&url=' + _esc(gadgetUrl);
    xmlhttp.open('GET', adsv_url + '&et=' + _et + '&r=' + Math.random(), true);
    xmlhttp.onreadystatechange = function() {
      if (xmlhttp.readyState == 4) {
        GSV_progressCounter--;
        _this.isGadgetUTF8 = xmlhttp.getResponseHeader('X-Is-Valid-UTF8');
      }
    };

    xmlhttp.send(null);
  }

  this.rules = [
    { tags: 'consumer ads',
      rule_name: 'XML_DECLARATION',
      check: function() {
        var re =
            /^<\?xml\s+version=['"]\d\.\d['"]\s+encoding=['"]UTF-8['"]\s*\?>/;
        var success = _this.gadgetSpec.getRawXml().match(re) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer',
      rule_name: 'MODULEPREFS_AUTHOR',
      check: function() {
        var xpath = '/Module/ModulePrefs/@author';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer',
      rule_name: 'MODULEPREFS_AUTHOR_EMAIL',
      check: function() {
        var moduleprefs =
            _this.gadgetSpec.selectSingleNode('/Module/ModulePrefs');
        var email = moduleprefs.getAttribute('author_email');
        var re = /^([\w+\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9:]{2,4})+$/;
        var success = email && email.match(re);
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer ads',
      rule_name: 'MODULEPREFS_HEIGHT',
      check: function() {
        var xpath = '/Module/ModulePrefs/@height';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer ads',
      rule_name: 'MODULEPREFS_WIDTH',
      check: function() {
        var xpath = '/Module/ModulePrefs/@width';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer ads',
      rule_name: 'MODULEPREFS_TITLE',
      check: function() {
        var xpath = '/Module/ModulePrefs/@title';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer',
      rule_name: 'MODULEPREFS_DESCRIPTION',
      check: function() {
        var xpath = '/Module/ModulePrefs/@description';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'consumer',
      rule_name: 'MODULEPREFS_THUMBNAIL',
      check: function() {
        var xpath = '/Module/ModulePrefs/@thumbnail';
        var att = _this.gadgetSpec.selectSingleNode(xpath);
        // TODO(daniellee): Tweak regexp to verify image extensions
        var success = Boolean(att && att.value.match(/^https?:\/\//));
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'ads',
      rule_name: 'GADGET_ADS_INLINE_ONLY',
      check: function() {
        var xpath = '/Module/Content[@type!="html"]';
        var success = _this.gadgetSpec.selectSingleNode(xpath) == null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'ads',
      rule_name: 'GADGET_ADS_NO_SCROLLING',
      check: function() {
        var moduleprefs =
            _this.gadgetSpec.selectSingleNode('/Module/ModulePrefs');
        var scrolling = moduleprefs.getAttribute('scrolling');
        var success = (scrolling && scrolling == 'false');
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'ads',
      rule_name: 'GADGET_ADS_VALIDATE_SIZE',
      check: function() {
        // Verify at least one ad size is defined and matches any of the
        // following AdSense formats:
        // 728x90, 468x60, 300x250, 336x280, 160x600
        // 120x600, 180x150, 120x240, 200x200, 250x250
        var adsenseFormats = {
          '728x90': 'Leaderboard',
          '468x60': 'Banner',
          '120x600': 'Skyscraper',
          '160x600': 'Wide Skyscraper',
          '200x200': 'Small Square',
          '250x250': 'Square',
          '300x250': 'Medium Rectangle',
          '336x280': 'Large Rectangle'
        };

        // Initialize a result object.
        var result = new GSV_ValidationResult(true, this.rule_name);

        // Verify that each available size is a valid dimension.
        var availSizes = _this.gadgetSpec.getAvailableSizes();
        if (availSizes.length > 0) {
          for (var n = 0; n < availSizes.length; n++) {
            var size = availSizes[n].join('x');
            if (!adsenseFormats[size]) {
              result.success = false;
              result.details.push('Invalid size: ' +
                  '<code>' + size + '</code>');
            }
          }
        } else {
          // No sizes were defined or detected.
          result.success = false;
          result.details.push('Gadget XML does not define any sizes.')
        }

        return result;
      }
    },
    { tags: 'ics',
      rule_name: 'ICS_VALIDATE_SIZE',
      check: function() {
        // Initialize a result object.
        var result = new GSV_ValidationResult(false, this.rule_name);

        // Check if the gadget supports the specific size passed in.
        // Only perform this check if the all available sizes are valid.
        var availSizes = _this.gadgetSpec.getAvailableSizes();
        if (_this.validateSize) {
          var validateSizeStr = _this.validateSize.join('x');
          for (var n = 0; n < availSizes.length; n++) {
            if (validateSizeStr == availSizes[n].join('x')) {
              // Found a match.
              result.success = true;
              _this.dimensions.push(availSizes[n]);
              break;
            }
          }

          // If unsuccessful, add an error message about this validation error.
          if (!result.success) {
            result.details.push('Gadget does not support this size: ' +
                '<code>' + validateSizeStr + '</code>');
          }
        } else {
          // No specific size to validate.  Verify that only a single size is
          // defined.  Otherwise, return an error.
          if (availSizes.length == 1) {
            result.success = true;
            _this.dimensions.push(availSizes[0]);
          } else {
            result.details.push('ICS is expecting a single ad size, but ' +
                (availSizes.length > 1 ? 'multiple' : 'no') +
                ' sizes are defined.');
          }

          // Update rule_name to use a different message bundle set for this
          // particular error.
          result.rule_name = 'ICS_VALIDATE_SIZE_NONE';
        }

        return result;
      }
    },
    { tags: 'ads',
      rule_name: 'VALIDATE_CLICKURL_USERPREF',
      check: function() {
        var xpath = '/Module/UserPref[@name="clickurl"][@datatype="hidden"]';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'ads',
      rule_name: 'VALIDATE_ADS_INTERACTION_TRACKING',
      check: function() {
        var xpath = '/Module/UserPref[@name="aiturl"][@datatype="hidden"]';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'ads',
      rule_name: 'REQUIRE_ADS_FEATURE',
      check: function() {
        var xpath = '/Module/ModulePrefs/Require[@feature="ads"]';
        var success = _this.gadgetSpec.selectSingleNode(xpath) != null;
        return new GSV_ValidationResult(success, this.rule_name);
      }
    },
    { tags: 'ads',
      rule_name: 'VERIFY_CHARACTERS_ARE_UTF8',
      check: function() {
        var success = (_this.isGadgetUTF8 == 1);
        var result = new GSV_ValidationResult(success, this.rule_name);
        if (!success) {
          if (_this.isGadgetUTF8 == -1) {
            result.details = [
              'Gadget spec must be in a saved file for UTF-8 verification.'
              ];
          } else {
            result.details = ['Gadget spec must be UTF-8.'];
          }
        }
        return result;
      }
    },
    { tags: 'ads',
      rule_name: 'NO_HANGMAN_VARIABLES',
      check: function() {
        // Verify gadget spec does not contain any hangman variables for
        // clickurl and aiturl.
        var rawXml = _this.gadgetSpec.getRawXml();
        var hangmanExists = /__UP_(?:clickurl|aiturl)__/.test(rawXml);
        var result = new GSV_ValidationResult(!hangmanExists, this.rule_name);
        return result;
      }
    },
    { tags: 'ads',
      rule_name: 'NO_PAGE_URL_USERPREF',
      check: function() {
        // Verify 'page_url' UserPref isn't defined in the spec.
        var pageUrlExists = false;
        var xmlDoc = _this.gadgetSpec.getXmlDocument();
        var prefs = xmlDoc.getElementsByTagName('UserPref');
        for (var n = 0, el; el = prefs[n]; n++) {
          if (el.getAttribute('name') == 'page_url') {
            pageUrlExists = true;
            break;
          }
        }

        var result = new GSV_ValidationResult(!pageUrlExists, this.rule_name);
        return result;
      }
    }
  ];
}

// variables to help capture end of async XHR calls
var GSV_progressCounter = 0;
var GSV_intervalId;

/**
 * Set the size of the gadget ad being validated.  One of the rules below
 * will verify that the gadget supports this size.
 * @this {StaticValidator}
 * @param {Array.<number>} size Array with 2 elements: [0]=width, [1]=height
 */
function GSV_setValidateSize(size) {
  if (size instanceof Array && size.length == 2) {
    this.validateSize = size;
  }
}

function GSV_validateCallback(tags, doneCallback, perRuleCallback) {
  if (GSV_progressCounter == 0) {
    var totalResult = new GSV_TotalResult();
    totalResult.success = true;
    totalResult.dimensions = this.dimensions;

    // stop the setInterval
    clearInterval(GSV_intervalId);

    // Create a mechanism to remove specific validation rules.
    var skipTags = new Array();
    if (this.gadgetSpec.isMultiSize()) {
      // If gadget contains multiple sizes, skip width/height validation.
      skipTags.push('MODULEPREFS_WIDTH');
      skipTags.push('MODULEPREFS_HEIGHT');
    }

    for (var i = 0, rule; rule = this.rules[i]; i++) {
      if (rule.tags.match('\\b' + tags + '\\b') ||
          rule.rule_name.match(tags)) {
        // Skip rules that are listed in the skipTags Array.
        if (!rule.rule_name.match(new RegExp('^' + skipTags.join('|') + '$'))) {
          var result = rule.check();
          if (perRuleCallback) {
            perRuleCallback(result);
          }
          totalResult.results.push(result);
          totalResult.success = totalResult.success && result.success;
        }
      }
    }
    if (doneCallback) {
      doneCallback(totalResult);
    }
  }
}

function GSV_validate(tags, doneCallback, perRuleCallback) {
  function unableToLoadXML(errmsg) {
    var result = new GSV_ValidationResult(false, 'XML_PARSER',
        'Unable to parse XML', errmsg);
    if (perRuleCallback) perRuleCallback(result);
    if (doneCallback) {
      var totalResult = new GSV_TotalResult();
      totalResult.results.push(result);
      doneCallback(totalResult);
    }
  }

  try {
    var xmlDoc = this.gadgetSpec.getXmlDocument();
    // IE check for failure
    if (xmlDoc.parseError.errorCode != 0) {
      unableToLoadXML(xmlDoc.parseError.reason);
      return;
    }
  } catch (e) {
    // FF check for failure.
    if (xmlDoc.documentElement.nodeName == 'parsererror') {
      unableToLoadXML(xmlDoc.documentElement.textContent);
      return;
    }
  }

  var instance = this;
  // check for completeness every second
  GSV_intervalId = setInterval(
      function() {
        instance.validateCallback(tags, doneCallback, perRuleCallback);
      }, 1000);

  // wait a maximum of 5 seconds
  setTimeout(
      function() {
        // if progress is not complete, then stop and report results
        if (GSV_progressCounter > 0) {
          GSV_progressCounter = 0;
          instance.validateCallback(tags, doneCallback, perRuleCallback);
        }
      }, 5000);
}

function GSV_getXmlHttp() {
  var x = null;
  if (window.XMLHttpRequest) {
    x = new XMLHttpRequest();
  } else if (window.ActiveXObject) {
    x = new ActiveXObject('Msxml2.XMLHTTP');
    if (!x) {
     x = new ActiveXObject('Microsoft.XMLHTTP');
    }
  }
  return x;
}

